Treat a bulk operation as one revertible unit in action_log and rollback - #569
Conversation
mureo had rollback, but it reasoned about one allow-listed operation at a time, so "undo what I did on Monday" was not expressible: after a bulk pass the operator had to work out by hand which entries the change set contained. An unverifiable revert is nearly as bad as no revert — it leaves the operator unable to rule their own fix out as a variable. Batch boundary. A bulk pass is many tool calls and nothing in a single call says which others belong with it, so the boundary is declared, not guessed: mureo_batch_begin / mureo_batch_end / mureo_batch_status. Inferring it from timing or target would be a heuristic, and a heuristic that silently omits a member re-creates the failure this exists to prevent. Membership is stamped where every recording path already converges (append_action_log), inside the state lock — not through tool arguments. That is what makes it platform-agnostic with no per-platform code and no ABI change: a native status toggle, a hosted-connector mutation an agent records, and a bridged/plugin call mureo promotes all join the same batch, including tools whose input schemas mureo does not own. Reversals appended by rollback_apply are excluded, or reverting a batch would grow it. rollback_plan_get accepts batch_id and returns a plan covering EVERY member: coverage (full / partial / none / empty), the same verdict per platform, per-member reversibility, the reason each irreversible member cannot be reversed, and an apply_order. Reversibility is not uniform across platforms, and a plan listing only the reversible members would read as a complete revert; a batch where 60 of 80 can be restored says so before anything is applied. Each member is classified by the existing plan_rollback allow-list, so grouping loosens no guarantee. Honest limits, documented rather than smoothed over: native mutations other than status toggles join only when the agent records them; a bridged/plugin reversal executes only when it names a registered plugin tool; hosted connectors are never reversed by mureo; Search Console mutations are not in action_log at all and cannot join a batch today. STATE.json gains an optional batches array and an optional batch_id per action_log entry, both emitted only when present — an existing file parses unchanged and gains no new key on the next write.
Three review findings from PR #569. Membership was not tamper-proof. mureo_state_action_log_append accepted any batch_id string: with no batch ever opened, an append could conjure a change set that rollback_plan_get then reported as legitimate, and an append could rejoin a batch already closed — making the member_count mureo_batch_end had reported silently false. A change set whose membership drifts after it was reported is the "reconstruct it from memory" problem wearing a batch id. An explicit batch_id is now validated, not trusted: it must name a declared batch that is still open. The check sits in append_action_log, inside the lock, so no caller — handler, library user or future recorder — bypasses it. Closing is final; backfill and import declare their own batch rather than retrofitting someone else's, which also gives the imported set an honest label and start time. A forgotten end swallowed everything after it. The asymmetry matters: a missed begin yields no batch, which is obvious and harmless, while a missed end yields a batch that keeps collecting unrelated changes for days and then reports them, confidently, as one unit. Both halves of the signal are now there — mureo_batch_status carries a warning for the caller who asks, and one is appended to every mutating tool result for the caller who forgot and therefore is not asking. The push half is the one that reaches the person with the problem. Nothing is auto-closed: a timeout would trade a visible wrong answer for an invisible one, since entries after it would stop joining with no one told. _resolve_path moved from _handlers_mureo_context to _helpers as resolve_workspace_path. It is the workspace sandbox boundary; a sibling module reaching into another handler's privates to borrow a security check is a place for the two to drift. Tests: batch membership can be neither forged nor grown after close (including the reviewer's exact reproduction through the MCP tool), staleness warns and never auto-closes, an unparseable start is not reported as fresh, and the reminder respects MUREO_DISABLE_BATCH_REMINDER. Also adds a batch containing native and bridged read-only actions, which pins the known is_read_only_tool_name defect (native verbs are suffixes, not prefixes) with the assertion the follow-up PR will flip.
|
Review fixes pushed in 8f2681b. All three MEDIUMs addressed; the pre-existing 1. Membership is now tamper-proofAn explicit Closed batches are refused, deliberately.
The reviewer's exact reproduction is now a test — no 2. A forgotten
|
| Injection | Result |
|---|---|
ensure_joinable accepts any id |
2 failed |
| closed batches rejoinable | 1 failed |
stale_batch_warning always None |
4 failed |
Three cleanups from the second review. batch_open_hours fell back to datetime.now(timezone.utc), and both production callers (stale_batch_warning, maybe_build_batch_reminder) omit ``now`` — so the production path was the only caller outside the one clock seam (#460), and every test here passes ``now`` explicitly, meaning a drift back to the wall clock would have gone unnoticed. It now defaults to clock.server_now(), resolved through the MODULE so monkeypatching the seam still works. The import is lazy, and has to be: mureo.core.__init__ -> runtime_context -> state_store -> mureo.context.state -> mureo.context.batch is a real chain, so a module-level ``from mureo.core import clock`` here raises ImportError on a partially initialised module. Verified, not assumed. A test now freezes clock.server_now and asserts the verdict follows it, so the seam is guarded rather than merely used: reverting to datetime.now fails that test and nothing else. Retargets three comments still naming the old private _resolve_path at mureo/context/batch.py, mureo/context/conversion_overrides.py and tests/test_mcp_tools_mureo_context.py. (mureo/policy/declarations._resolve_path is an unrelated function of the same name and is left alone.) Makes the deferred-fix test docstring self-sufficient, since no issue is being filed: it now states the defect with the failing call, names all 13 mutating plugin tools a naive suffix rule would strip of their guardrail money scan (reporting-delete_report among them), says what a correct fix must do and which test files it touches, and names the single assertion that flips when it lands.
|
Cleanups pushed in 4d88064. MEDIUM — batch staleness now goes through the clock seam
One deviation from the snippet you gave, forced by the import graph. A module-level Verified rather than assumed — No LOW — stale
|
Root cause of the CRITICAL found reviewing the stacked #545 PR. stamp_batch enumerated ActionLogEntry's fields to produce the batch-stamped copy, so any field added to the dataclass afterwards was dropped the moment an entry joined an open batch. join_active_batch defaults to True and both the import path and mureo_state_action_log_append use the default, so this was the ordinary path. The loss was silent, which is what made it dangerous: a dropped field is indistinguishable downstream from one the caller never set. #545's provenance fields (origin / external_id) went with it, and since is_external is derived from origin, an externally-imported entry lost the marker that stops a forged reversible_params from being planned as a real reversal — turning NOT_SUPPORTED into SUPPORTED. Fixed as a class of bug, not an instance: dataclasses.replace(entry, batch_id=...) carries every field across by construction, so no future field can opt out of batching by omission. Same defect shape as the agency #193 bug where update() rebuilt a registry entry field-by-field and dropped archived. Audited the other six ActionLogEntry construction sites. Five build genuinely new entries from scratch (native_reversal, plugin_semantics, creative_studio x2, rollback executor) and have nothing to carry over. The sixth, state_codec._parse_action_log_entry, enumerates because it must — it maps to an external JSON schema — and its omission is silent in the same way, so the round-trip test below covers it too. Tests are driven off dataclasses.fields(ActionLogEntry), not a hand-written list, so they cannot rot the way the code did: one asserts stamp_batch changes only batch_id, the other that every field survives append_action_log with an open batch and a trip through STATE.json. Adding a field to the dataclass without adding it to the test's value map fails loudly with an explanatory message.
|
Fixed in 2d5d5c8. The bug is mine and the diagnosis is right — thanks for tracing it back here rather than patching it in #545. The fix
The docstring records why, including the concrete cost — that an enumerated rebuild dropped Audit of the other
|
| Site | Verdict |
|---|---|
native_reversal.py:222 |
New entry from scratch — nothing to carry over |
plugin_semantics.py:416 |
Same |
tools_creative_studio.py:505, :738 |
Same |
rollback/executor.py:194 |
Same |
_handlers_mureo_context.py:289 |
Builds from an untrusted MCP dict; enumerating is correct here — it is the whitelist of what a caller may set |
state_codec.py:291 |
Enumerates, and must — it maps to an external JSON schema, so replace cannot help. Its omission is silent in exactly the same way, so the round-trip test below covers it |
stamp_batch was the only rebuild-of-an-existing-entry.
Tests
Both driven off dataclasses.fields(ActionLogEntry), not a hand-written subset:
test_stamp_batch_changes_only_batch_id— the pure function, so a failure localizes here rather than in the codec.test_every_field_survives_the_append_round_trip— throughstamp_batch, the codec, onto disk and back.
Verified against three separate failure modes by injecting a simulated #545 origin field:
| Injection | Result |
|---|---|
simulated origin + the old enumerating stamp_batch |
both tests fail (this is the reported bug) |
simulated origin, stamp_batch fixed, codec unaware of it |
round-trip test fails — so the codec half is genuinely covered |
simulated origin on the dataclass, absent from the test's value map |
both fail with ActionLogEntry gained field(s) ['origin'] with no value in _ENTRY_FIELD_VALUES… |
That third one is the anti-rot property: the test cannot silently stop covering a field the way the code silently stopped copying one.
One observation, not acted on
The same shape is live one level up. StateDocument is rebuilt field-by-field in five mureo/context/state.py mutators (append_action_log, upsert_campaign, set_report, set_platform_metrics, set_conversion_action_types). That is pre-existing, but this PR is direct evidence it bites: adding batches required hand-threading it into all five, and missing one would have silently closed an open batch on the next campaign upsert.
end_batch and begin_batch already use replace. Converting the other five is mechanical but touches merge semantics with real comments attached, and you said this was the only change — so I have not. Say the word and I will, in its own PR, with a field-list-driven preservation test per mutator.
Gates
ruff / black --check / mypy clean. pytest: 7809 passed, 12 failed — the identical 12 that fail on origin/main here.
One note on that number: an earlier run showed 13, with test_web_handlers.py::TestServeAbout::test_about_unmocked_lists_mureo added. It is not related — that test binds a real local HTTP server with a 2s urlopen timeout, and a sibling worktree was running its own full suite at the same time. It passes in isolation and did not recur on a clean re-run. Flagging rather than quietly reporting the good number.
Resolves the conflicts #549 accumulated while main moved: - state.py: main refactored the STATE.json mutators onto dataclasses.replace(), which preserves unlisted fields structurally — including the new `batches`. Took main's side for the four mutators that were only enumerating fields defensively, and combined the fifth (append_action_log) so batch validation/stamping runs on top of replace(). - state_codec.py: main's new import-time codec-coverage guard (#576) did not know about `batches`, `ActionLogEntry.batch_id`, or `BatchRecord`. Both codec halves already handled them; only the declaration was missing, which is exactly what the guard is for. - test_dataclass_field_preservation.py: gave the new fields distinctive values so the round-trip actually exercises them. - Tool count 208 + 213 -> 216 (the 3 batch tools on top of main's 213), pinned in test_mcp_server.py and restated in the READMEs, mcp-server.md and architecture.md. - AGENTS.md / architecture.md / CHANGELOG.md: additive union of both sides.
* feat: treat a bulk operation as one revertible unit (#549) mureo had rollback, but it reasoned about one allow-listed operation at a time, so "undo what I did on Monday" was not expressible: after a bulk pass the operator had to work out by hand which entries the change set contained. An unverifiable revert is nearly as bad as no revert — it leaves the operator unable to rule their own fix out as a variable. Batch boundary. A bulk pass is many tool calls and nothing in a single call says which others belong with it, so the boundary is declared, not guessed: mureo_batch_begin / mureo_batch_end / mureo_batch_status. Inferring it from timing or target would be a heuristic, and a heuristic that silently omits a member re-creates the failure this exists to prevent. Membership is stamped where every recording path already converges (append_action_log), inside the state lock — not through tool arguments. That is what makes it platform-agnostic with no per-platform code and no ABI change: a native status toggle, a hosted-connector mutation an agent records, and a bridged/plugin call mureo promotes all join the same batch, including tools whose input schemas mureo does not own. Reversals appended by rollback_apply are excluded, or reverting a batch would grow it. rollback_plan_get accepts batch_id and returns a plan covering EVERY member: coverage (full / partial / none / empty), the same verdict per platform, per-member reversibility, the reason each irreversible member cannot be reversed, and an apply_order. Reversibility is not uniform across platforms, and a plan listing only the reversible members would read as a complete revert; a batch where 60 of 80 can be restored says so before anything is applied. Each member is classified by the existing plan_rollback allow-list, so grouping loosens no guarantee. Honest limits, documented rather than smoothed over: native mutations other than status toggles join only when the agent records them; a bridged/plugin reversal executes only when it names a registered plugin tool; hosted connectors are never reversed by mureo; Search Console mutations are not in action_log at all and cannot join a batch today. STATE.json gains an optional batches array and an optional batch_id per action_log entry, both emitted only when present — an existing file parses unchanged and gains no new key on the next write. * fix: harden batch membership and signal a forgotten batch (#549 review) Three review findings from PR #569. Membership was not tamper-proof. mureo_state_action_log_append accepted any batch_id string: with no batch ever opened, an append could conjure a change set that rollback_plan_get then reported as legitimate, and an append could rejoin a batch already closed — making the member_count mureo_batch_end had reported silently false. A change set whose membership drifts after it was reported is the "reconstruct it from memory" problem wearing a batch id. An explicit batch_id is now validated, not trusted: it must name a declared batch that is still open. The check sits in append_action_log, inside the lock, so no caller — handler, library user or future recorder — bypasses it. Closing is final; backfill and import declare their own batch rather than retrofitting someone else's, which also gives the imported set an honest label and start time. A forgotten end swallowed everything after it. The asymmetry matters: a missed begin yields no batch, which is obvious and harmless, while a missed end yields a batch that keeps collecting unrelated changes for days and then reports them, confidently, as one unit. Both halves of the signal are now there — mureo_batch_status carries a warning for the caller who asks, and one is appended to every mutating tool result for the caller who forgot and therefore is not asking. The push half is the one that reaches the person with the problem. Nothing is auto-closed: a timeout would trade a visible wrong answer for an invisible one, since entries after it would stop joining with no one told. _resolve_path moved from _handlers_mureo_context to _helpers as resolve_workspace_path. It is the workspace sandbox boundary; a sibling module reaching into another handler's privates to borrow a security check is a place for the two to drift. Tests: batch membership can be neither forged nor grown after close (including the reviewer's exact reproduction through the MCP tool), staleness warns and never auto-closes, an unparseable start is not reported as fresh, and the reminder respects MUREO_DISABLE_BATCH_REMINDER. Also adds a batch containing native and bridged read-only actions, which pins the known is_read_only_tool_name defect (native verbs are suffixes, not prefixes) with the assertion the follow-up PR will flip. * fix: route batch staleness through the server clock seam (#549 review) Three cleanups from the second review. batch_open_hours fell back to datetime.now(timezone.utc), and both production callers (stale_batch_warning, maybe_build_batch_reminder) omit ``now`` — so the production path was the only caller outside the one clock seam (#460), and every test here passes ``now`` explicitly, meaning a drift back to the wall clock would have gone unnoticed. It now defaults to clock.server_now(), resolved through the MODULE so monkeypatching the seam still works. The import is lazy, and has to be: mureo.core.__init__ -> runtime_context -> state_store -> mureo.context.state -> mureo.context.batch is a real chain, so a module-level ``from mureo.core import clock`` here raises ImportError on a partially initialised module. Verified, not assumed. A test now freezes clock.server_now and asserts the verdict follows it, so the seam is guarded rather than merely used: reverting to datetime.now fails that test and nothing else. Retargets three comments still naming the old private _resolve_path at mureo/context/batch.py, mureo/context/conversion_overrides.py and tests/test_mcp_tools_mureo_context.py. (mureo/policy/declarations._resolve_path is an unrelated function of the same name and is left alone.) Makes the deferred-fix test docstring self-sufficient, since no issue is being filed: it now states the defect with the failing call, names all 13 mutating plugin tools a naive suffix rule would strip of their guardrail money scan (reporting-delete_report among them), says what a correct fix must do and which test files it touches, and names the single assertion that flips when it lands. * feat: import externally-made changes into action_log (#545) Every guarantee mureo offers hung off mureo having *made* the change. An operator working in a platform's own UI — normal professional work, not misuse — never went through StrategyPolicyGate, never reached action_log, never got an observation_due, and never appeared in /daily-check's evidence step. The consequence was not a thin log but a blind one: mureo could not tell "nothing happened" from "something happened that I cannot see". In the originating incident that gap cost roughly six days of failed diagnosis, because nothing connected "delivery died" to "exclusions were added". mureo_external_changes_import polls each configured platform's change feed and appends what mureo did not do. /daily-check runs it at step 2b, before it diffs or diagnoses anything. Idempotent — importing the same change twice is a no-op. Observed is not performed. ActionLogEntry gains origin / external_id / occurred_at (optional, emitted only when set, so an existing STATE.json parses unchanged and gains no new key). An imported entry can never be mistaken for one mureo dispatched, and plan_rollback refuses every external entry before any other check — even one carrying a well-formed reversal hint. mureo never captured the prior value, so a reversal built from such a hint is a fresh change dressed as a restoration. A batch mixing the two therefore reports partial coverage rather than promising a revert it could only half deliver. The observation window anchors on when the change happened, not on when mureo noticed: a change made three weeks ago lands already past due. metrics_at_action is deliberately unset — mureo was not there, and a synthesised baseline would have mureo_outcome_evaluate score a delta against a "before" that never existed. mureo's own changes are not double-counted. Every change mureo dispatches also appears in the feed, and the feed's attribution fields cannot separate them: user_email is the same OAuth identity either way, and GOOGLE_ADS_API covers every API tool on the account. The discriminator is mureo's own log — same platform, same target identity, within 10 minutes. Where identity is missing the match cannot be made and the change is imported as external: an over-import is visible and correctable, an over-attribution silently swallows a real UI edit. Missing coverage is reported, never smoothed over. Every configured platform appears in the response. No feed returns change_import_unavailable_for_<platform> (the analytics_not_available_for_<platform> contract); a failed feed returns error. Neither means "no changes" — the absence of a change feed is not evidence of innocence. A capped response returns truncated: true, because change_event returns at most 100 rows with no paging and retains ~30 days: history cannot be reconstructed after the fact, only captured continuously. Coverage today: Google Ads is read. Meta Ads is not — the Ad Account Activity edge exists on Meta's side and mureo ships no client for it. Amazon (bridge), Yahoo, LINE and SmartNews (plugins) can opt in through the ABI hook and do not today. TikTok is a hosted connector outside mureo's data path entirely; a skill records what it reads there via mureo_state_action_log_append with origin: "external". The full table is in docs/change-import.md. ABI hook: a new ChangeFeedProvider Protocol in a new mureo.change_feeds entry-point group. A new Protocol because adding a method to a runtime_checkable one de-registers every published implementation (ABI-stability §4); a new group so a bridge wanting only a change feed need not stub four analytics methods it will never implement. Installed plugins are unaffected in every way. Google Ads' change_event query additionally selects resource_name, client_type, campaign, ad_group and changed_resource_name. Purely additive — every field google_ads_change_history_list returned before is unchanged. Closes #545 * fix: close three attribution and honesty gaps in change import (#545 review) Attribution compared platform, identity and a 10-minute window but not the KIND of change, so mureo pausing campaign 111 swallowed the operator's budget edit on campaign 111 four minutes later. Identity-only matching went straight through the principle the module's own docstring states — an over-attribution silently discards a real UI edit — and manual work overlapping mureo-driven work on one campaign is the common case after onboarding, not a corner. A match now also requires the same kind of change, from a small shared vocabulary (status / budget / bid / criterion / ad / ad_group / campaign) derived independently from each side: changed_fields before resource_type on the feed side (a budget edit arrives as CAMPAIGN + changed_fields=[campaign_budget] as often as CAMPAIGN_BUDGET), tool name matched verb-before-noun on the log side. Both sides must yield a kind and the two must be equal — "unknown matches anything" would restore the old behaviour for every action mureo cannot classify. A definite create-vs-remove disagreement additionally refutes a match, catching mureo removing a negative keyword while the operator adds one; it only refutes, never confirms, since a *_update tool may be an upsert. What kind matching still cannot discriminate — two same-kind changes on one target inside the window, free-text action names, sub-kind detail — is now written down in docs/change-import.md rather than left as an unmet claim. BYOD reported IMPORTED with nothing imported. ChangeImportStatus's own docstring says IMPORTED means the feed ran, blind_spots collects only UNAVAILABLE and ERROR, and daily-check step 2b tells the agent a platform absent from blind_spots was looked at — so a mode with no change history at all was reported as checked and quiet. That is the exact collapse this feature removes, reintroduced by the feature. ChangeFeedResult gains an optional unavailable_reason: a registered feed that could not answer for this account or mode sets it, and the importer maps it to UNAVAILABLE. Additive with a default, so no published feed changes. The BYOD test asserted only on notes and passed regardless; it now asserts the status, and a second test carries the signal end to end into blind_spots. Imported changes no longer join an open batch. A batch is the operator's declared change set; a change mureo merely observed is not something they did through mureo, and letting it join dropped the whole batch's rollback coverage to partial over an unrelated UI edit that happened to be polled while the batch was open. Same reasoning as the rollback executor's own rollback_of exclusion. Also documents, next to ATTRIBUTION_WINDOW_MINUTES, that widening the window can only add attributions — the least certain ones, failing silently — while narrowing it can only add over-imports, which the operator sees. * fix: stamp_batch must not rebuild ActionLogEntry field-by-field (#549) Root cause of the CRITICAL found reviewing the stacked #545 PR. stamp_batch enumerated ActionLogEntry's fields to produce the batch-stamped copy, so any field added to the dataclass afterwards was dropped the moment an entry joined an open batch. join_active_batch defaults to True and both the import path and mureo_state_action_log_append use the default, so this was the ordinary path. The loss was silent, which is what made it dangerous: a dropped field is indistinguishable downstream from one the caller never set. #545's provenance fields (origin / external_id) went with it, and since is_external is derived from origin, an externally-imported entry lost the marker that stops a forged reversible_params from being planned as a real reversal — turning NOT_SUPPORTED into SUPPORTED. Fixed as a class of bug, not an instance: dataclasses.replace(entry, batch_id=...) carries every field across by construction, so no future field can opt out of batching by omission. Same defect shape as the agency #193 bug where update() rebuilt a registry entry field-by-field and dropped archived. Audited the other six ActionLogEntry construction sites. Five build genuinely new entries from scratch (native_reversal, plugin_semantics, creative_studio x2, rollback executor) and have nothing to carry over. The sixth, state_codec._parse_action_log_entry, enumerates because it must — it maps to an external JSON schema — and its omission is silent in the same way, so the round-trip test below covers it too. Tests are driven off dataclasses.fields(ActionLogEntry), not a hand-written list, so they cannot rot the way the code did: one asserts stamp_batch changes only batch_id, the other that every field survives append_action_log with an open batch and a trip through STATE.json. Adding a field to the dataclass without adding it to the test's value map fails loudly with an explanatory message. * test: pin provenance through the batch-stamping path, and name the residual limit Completes the verification the stamp_batch fix (2d5d5c8) was needed for, on the path that is actually at risk. The importer passes join_active_batch=False so it never reaches stamp_batch at all; a hosted connector does, because mureo holds no credentials for TikTok and an agent records what it read from the connector's own change history through mureo_state_action_log_append — the ordinary append path, where joining the open batch is the default. Three tests cover that chain rather than the stamping function: with a batch open, an appended external entry keeps origin / external_id / occurred_at and still reports is_external; plan_rollback returns NOT_SUPPORTED on a forged allow-listed reversal hint; and the batch plan reports the member as a gap rather than as covered. Reverting stamp_batch to the enumerated rebuild fails all three with the original symptoms (origin None, SUPPORTED, coverage full), so they catch the CRITICAL and not merely its neighbourhood. #549's round-trip guard is derived from dataclasses.fields(ActionLogEntry) and correctly refused the three new fields until values were supplied; added, with the note that these are the fields the enumerated version actually dropped. Documentation. The residual over-attribution — a hand edit to the SAME setting on the SAME entity within the attribution window of a mureo change is indistinguishable from mureo's own and is silently lost — was accurate but filed as one bullet among three, which is the wrong weight for the one case that fails in the expensive direction. It is the mixed-operation pattern of the incident behind #545, not an exotic shape. It now has its own section in docs/change-import.md, led by what the operator should actually do (wait out the ten minutes before editing that entity's same setting by hand, or say what was changed so the record does not depend on the import), and a matrix showing how narrow the case is — a different entity, or a different setting on the same entity, imports normally. The same advice is in the _mureo-shared skill, where an agent will read it and can raise it with the operator at the moment it applies. The two things that fail toward over-import instead are kept, demoted to their own list. * fix: require the SAME target, not an overlapping one, before attributing (#545 review) _identity_pairs intersected the two identity sets and accepted a non-empty result. That is "some slot agrees", not "no populated slot disagrees", and it inverted the module's stated bias at the identity layer: where identity could not be resolved it fell back to the coarsest shared field and attributed — failing toward the silent swallow the design exists to avoid. Two reproductions, both discarding a real operator edit: - mureo updates keyword kw-A's bid; four minutes later the operator edits kw-B's bid in the same campaign. entity_id disagreed outright but was never consulted, because the shared campaign_id alone satisfied the intersection. - mureo pauses one ad; the operator pauses the whole campaign. ad_id is simply absent on a campaign-level feed row, so the match fell back to the shared campaign_id and a small mureo action swallowed a much larger operator one. Two rules replace the intersection. No slot populated on both sides may disagree — rejecting on any disagreement rather than accepting on any agreement. And both sides must name their target at the same specificity (entity_id > ad_id > campaign_id, mureo's existing canonical-target precedence): a target and its container are not a match, and an asymmetry there is unresolved, which must mean import. Requiring equal specificity forces the feed side to speak mureo's identity language, so the Google adapter now names ONE canonical target per row: an ad-level row names the ad, parsed out of changed_resource_name, and not its parent ad group. Without that a feed row would look strictly more specific than mureo's own record of the same change and every ad-level action would re-import as external on each run. This is the rule plugin_semantics.extract_mutation_identity already states for plugin mutations. A row whose ad cannot be resolved names no sub-campaign target at all rather than falling back to the ad group. Tests: both reproductions, both asymmetry directions, and a nine-case identity matrix over same/different campaign, same/different entity, entity present on one side only, and inside/outside the window. Neither reproduction had a test — the suite only covered a different campaign entirely, which is how this shipped. docs/change-import.md claimed "Different entity, any setting, any time | Yes", which was false. The table now enumerates sibling, broader and narrower targets, all correctly imported, and says which row of an earlier version was wrong. The ATTRIBUTION_WINDOW_MINUTES note is re-checked: its asymmetry argument holds, and the window now multiplies only the documented same-entity/same-setting case rather than every edit inside a touched campaign. * fix: correct an invented proto field, and name criteria and ads as their own targets (#545 review) Three defects, one of them in code that already shipped. 1. changed_resource_name does not exist. The ChangeEvent proto field is change_resource_name — no "d". Verified against the vendored SDK (google-ads 29.2.0, v23): the descriptor lists change_resource_name and hasattr for the other spelling is False. It was in the GAQL SELECT, which the API would reject, and in map_change_event, where getattr silently yields "". So google_ads_change_history_list — a tool that ships today — was likely inert in production, and the "name the ad, not its ad group" identity work never fired against real traffic. The whole mapper was audited field by field against the descriptor; that was the only wrong name. It survived because every test built row dicts by hand and bypassed the mapper, and because MagicMock answers hasattr for any name and returns a truthy mock, so a misspelling passes every assertion. Two tests now close that: one drives map_change_event with a real ChangeEvent (a protobuf raises on a name it does not have), and one asserts every emitted key against the descriptor. A third asserts the same for the GAQL SELECT, which nothing checked at all — _search is mocked in every test there, so the query string itself was never read. 2. AD rows lost their identity. _ad_id_from_changed_resource parsed only adGroupAds/<ag>~<ad>, but AdService — which google_ads_ads_update reports through — names ads customers/<cid>/ads/<id>, with no ad-group segment and no "~". Those rows came back campaign-only, so every creative edit would re-import as external on every run: the exact effect the identity work exists to remove, on the other resource type. Both shapes are accepted now. 3. The flagship scenario was not closed. "mureo edits kw-A's bid, the operator edits kw-B's" needs criterion-level identity on both sides, and neither had it. extract_mutation_identity discarded criterion_id and fell through to the parent ad_group_id, and the feed named the ad group too — so two keywords in one ad group were one target and the operator's edit was still discarded. criterion_id (and criterionId) now outrank ad_group_id at capture, criterion rows name the criterion rather than their parent, and _mureo-shared tells the agent to pass the criterion id for keyword mutations. Native keyword mutations are still recorded only when the agent makes that call — nothing enforces the granularity — so docs/change-import.md now says the sibling-entity row holds only when both sides named the sibling, instead of claiming it unconditionally. Also: entity_type was documented as part of ExternalChange's identity and never compared, so an ad_group numbered 999 matched a keyword numbered 999. Unlikely on Google and Meta, guaranteed by nothing for a plugin declaring its own entity_type. The generic slot now compares type and id together. * test: sweep every GAQL builder against the proto descriptors (#545 review) Tonight's shipped defect — change_event.changed_resource_name, a field that does not exist — came from a gap rather than a typo: _search is mocked in every test that touches a GAQL builder, so no test ever read a query string, and the mapper reading the same wrong name returns "" instead of raising. The one query that was caught got a guard last commit; this is the sweep for the rest. tests/test_gaql_field_names.py extracts every SELECT in mureo/ and resolves each dotted field path against the vendored v23 protobuf descriptors, walking nested messages. Both the query list and the field list are derived — from the tree and from the SDK — because a hardcoded expectation goes stale silently, which is the failure mode being fixed. RESULT: CLEAN. 56 queries across 16 files, 360 field references, zero invalid. There is no second instance of the shipped bug. Two things had to be fixed in the checker before that result meant anything, and both are now asserted rather than assumed. It first reported 18 failures that were all false: proto-plus mangles names colliding with Python builtins (type -> type_) while GAQL uses the unmangled API name, so asset.type is correct and the checker was wrong. And it initially missed queries built from implicitly concatenated string literals, leaving 27 field references across two files unchecked while still reporting "clean" — test_extraction_covers_- every_query now pins the query count against an independent count of FROM clauses, and test_every_query_selects_at_least_one_field catches a query whose fields all failed to parse. AD_GROUP_BID_MODIFIER joins the criterion resource types. It uses the same composite parentId~criterionId shape, so omitting it left those rows collapsing onto the parent ad group — the defect the criterion entries exist to fix, left in place for one type. Not reachable through mureo's own tools (device bids go through CampaignCriterionService), but an operator's UI edit is, and this is a feed of changes mureo did not make. Every resource-name segment the adapter parses is now asserted against the SDK's own *_path builders rather than hardcoded, since a wrong segment yields "no identity" silently rather than raising. plugin_semantics documents the undeclared-identity fallback as a known property: its guesses carry hardcoded Google-shaped entity_type labels applied to third-party arguments that merely share a spelling. Inherent to guessing, unreachable through mureo's own built-ins, and avoidable by declaring identity — which is the supported route. * docs: drop the #549 paragraph the merge duplicated in architecture.md * fix: drop the _batch_record_to_dict copy the merge kept twice
Closes #549.
Why
Rollback existed but reasoned about one allow-listed operation at a time, so "undo what I did on Monday" was not expressible — after a bulk pass the operator had to reconstruct the member entities by hand. The incident behind this issue ended with 80+ entries deleted in one pass with no confidence the set matched what had been added, and because the revert itself could not be verified, the operator could not rule their own fix out as a variable.
What
1. The boundary is declared, not guessed
A bulk pass is many tool calls and nothing in a single call says which others belong with it. Inferring membership (same minute, same campaign, same tool) would be a heuristic, and a heuristic that silently omits a member re-creates exactly the failure this is meant to prevent. So:
mureo_batch_begin(takes alabel) /mureo_batch_end(returns the exactmember_indices+platforms) /mureo_batch_status.batches), not process memory, so a host restarting the MCP server mid-pass does not silently stop collecting. Records are kept after close (ended_at) so abatch_idstill resolves to the operator's own label later.2. Membership is stamped in core, at one choke point
Every recording path — native status toggles (
native_reversal),mureo_state_action_log_append, and bridged/plugin promotion (plugin_semantics.record_mutation_action_log) — already converges onappend_action_log. The stamp is applied there, inside the state lock.That is the whole cross-platform story: no per-platform code, no plugin ABI hook, no tool-schema change. Threading a
batch_idthrough tool arguments would have worked for native tools only — a bridged tool's input schema belongs to the bridged platform, not to mureo, and many declareadditionalProperties: false.Reversals appended by
rollback_applyare explicitly excluded (join_active_batch=False); otherwise reverting a batch would grow the batch it reverts.3.
rollback_plan_getplans a whole batch, gaps includedPass
batch_idinstead ofindexand the response covers every member:coverage—full/partial/none/emptyplatform_coverage— the same verdict per platform key, because reversibility is not uniform across platformscounts— reversible / reversible_with_caveats / irreversible / nothing_to_reverse / already_reversed / totalmembers[]— each with itsindex,platform,reversibilityand, when it is a gap, the reasonapply_order— the reversible indices, newest firstEach member is classified by the existing
plan_rollbackallow-list, so grouping loosens no guarantee. A batch where 60 of 80 members can be restored reportspartialwith the other 20 named and explained, before anything is applied — planning is pure and writes nothing.Honest limits (stated in the docs, not smoothed over)
mureo_state_action_log_appendplugin:<dist>:<provider>, e.g. Amazon Ads)irreversiblewith the reasontiktok_ads)mureo_state_action_log_append— mureo is not in the data pathaction_logat allCompatibility
ActionLogEntry.batch_idandStateDocument.batchesare both optional and emitted only when present, so an existing STATE.json parses unchanged and gains no new key on the next write (pinned by a round-trip test).batch_idis appended last on the dataclass, preserving positional-constructor compatibility.rollback_plan_get'sindexform is unchanged;batch_idis an additive alternative (oneOf).Tests
New
tests/test_batch_revertible_unit.py(21 tests), deliberately multi-platform: google_ads and meta_ads (native), tiktok_ads (hosted connector) and plugin:mureo-amazon-ads-bridge:amazon_ads (bridged), including a four-platform batch where each member entersaction_logthrough a different recording path, and a mixed-reversibility batch spanning three platforms.Not vacuous — two injected regressions, both caught:
batch_idpropagation instamp_batch→ 8 failuresNOT_SUPPORTEDmembers asREVERSIBLE→ 3 failures, includingtest_partial_reversibility_is_reported_before_anything_is_appliedandtest_coverage_is_reported_per_platformNot in this PR
rollback_applystill takes oneindexper call; applying a batch is a loop overapply_order. A single "apply the whole batch" result code would have to summarize partial failure into one status, which is the reporting problem this issue exists to remove. No CLI surface for batches —mureo rollback list/showremain entry-by-entry, noted indocs/cli.md.Docs
docs/mcp-server.md(tool count 205 → 208, new Batch section + batch-plan fields + the per-platform table),docs/strategy-context.md(batches+batch_idschema),docs/architecture.md,docs/cli.md,AGENTS.md,skills/_mureo-shared/SKILL.md+skills/search-term-cleanup/SKILL.md(and theirmureo/_data/skills/mirrors), CHANGELOG.Gates
ruff check ./black --check ./mypy mureo --ignore-missing-importsclean.python -m pytest: 7795 passed, 12 failed — the identical 12 that fail onorigin/mainin this environment (9 ×test_live_clients.py, plus 3 tool-registry tests that count locally-installed plugin tools). No new failures.